在今天我們將運用前面所學的關於檔案操作的 System call,以及 Process 相關的 System call,來實現 I/O 重導向的概念,並藉由 I/O 重導向的實現,來了解到系統分層的概念。最後將看到關於 pipe 的相關使用與實現。
我們之前常常使用 printf()
, scanf()
, putchar()
, getchar()
, puts()
等函數都是通過 stdin 預設的鍵盤進行輸入,並使用 stdout 預設的螢幕進行輸出,然而,在 UNIX 或 DOS 等作業系統中,我們可以通過重導向來改變輸入或是輸出來源。
echo hello > out
output:
這種示範就是輸出重導向 (input redirection),本質上是使 stdout 流表示為檔案,而非 Console (以檔案做為 output 即是使用這個概念)。所以我們沒有在 Console 中看到任何輸出,輸出位於檔案 out 中。
同理,我們可以將剛剛產生的 out 檔案作為輸入,使用 cat()
查看內容
cat < out
output:
hello
而 Shell 之所以可以這樣做,原因為 Shell 會進行 fork()
出一個子 Process,之後在子 Process 中,Shell 改變了檔案描述子,將檔案描述子由1變成了 out 所對應到的檔案描述子,接著執行我們的指令,由於子 Process 和親代 Process 擁有各自獨立的記憶體空間,因此子 Process 改變檔案描述子不會影響到親代 Process (親代 Process 的檔案描述子還是1 (stdout 標準輸出)。這是在 UNIX 中常見的重導向操作,我們只想改變子 Process 的輸出,而非親代 Process。
在Day-03,我們使用了fork()
,exec()
,wait()
, close()
這一些有關於 Process 的 System,現在,我們可以結合這一些工具實現 I/O 重導向的操作。
#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"
#include "kernel/fcntl.h"
int main(void)
{
int pid = 0;
pid = fork();
if(pid == 0)
{
close(1);
open("output.txt", O_WRONLY | O_CREATE);
char *argv[] = {"echo", "this", "is", "redirected", "echo", 0};
exec("echo", argv);
printf("exec failed!\n");
exit(1);
}
else
{
wait((int *)0);
}
exit(0);
return 0;
}
在第9行的地方,我們執行了 fork()
System call,接著會進入到子 Process 中。
在第12行的地方,我們執行了 close()
System call,並將檔案描述子1傳入,表示關閉檔案描述子1所關聯的檔案,在 Shell 的預設情況檔案描述子1是關連到標準輸出上,而我們將其關閉,也就是解除檔案描述子1和標準輸出的關聯,使得我們能將輸出輸出到其他檔案中。
在第13行的地方,建立並且以讀寫模式開啟了 output.txt,而 open 會回傳檔案描述子,前面說到在預設情況下,UNIX 會將2以上的數字作為開啟檔案之檔案描述子,但是在這邊由於我們解除了檔案描述子1與標準輸出的關聯,因此 open()
會回傳1,使得在第13行執行完畢後,檔案描述子1和 output.txt 關聯在了一起。
接著執行16行,子 Process 變成了 echo,以第15行的 argv 作為參數執行,而 echo 會將輸出輸出到檔案描述子1關連到了檔案中,這裡為 output.txt,接著親代 Process 等待子 Process echo 執行 exit()
,接著會傳到親代 Process 的 wait()
,而這裡我們傳入 wait()
的引數為0,型別為 int *
,表示我們不在意子 Process 的狀態。
echo 實際上並不知道自己是輸出到 Console 還是檔案上,只是知道輸出到了檔案描述子1關連到的檔案上
fork()
與 exec()
這裡我們可以看到將 fork()
與 exec()
分開所帶來的好處,親代 Process 可以重新導向子 Process 的 I/O 而不會影響到親代 Process 的 I/O,而在親代 Process 執行了 fork()
後產生出子 Process 並且得到回傳,到子 Process 執行到 exec()
這一段時間,子 Process 正在執行的還是親代 Process 的程式碼,因此在程式碼第10行到16行這一段區間,我們還可以使用親代 Process 去對子 Process 作出一些影響。
如果把 fork()
和 exec()
寫在一起,Shell 就需要在 forkexec()
執行之前修改 I/O 設置,且在 forkexec()
成功執行後取消修改。
pipe()
pipe()
為在 kernel 中一塊的緩衝區,而 pipe()
需要接收一個長度為2的整數陣列,內容為連接到輸入的檔案描述子以及連接到輸出的檔案描述子,將資料寫入到 pipe()
的一端,而另外的 Process 就可以從 pipe()
的另外一端讀取,實現 Process 之間的通訊。
// user/wc.c
#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"
int main(void)
{
int p[2];
char *argv[2] = {"wc", 0};
pipe(p);
if(fork() == 0)
{
close(0);
dup(p[0]);
close(p[0]);
close(p[1]);
exec("/bin/wc", argv);
}
else
{
write(p[1], "hello world\n", 12);
close(p[0]);
close(p[1]);
}
return 0;
}
說明:
在最一開始的狀態,檔案描述子0是連接到標準輸入上,p[0]
為 pipe 的讀取端,p[1]
為 pipe 的寫入端
接著在第11行執行fork()
System call,產生出子 Process,子 Process 的 PID 為0,進入到第13行。(fork()
後親代 Process 和子 Process 都有 pipe 的檔案描述子)
第13行解除檔案描述子0關連到的檔案,也就是解除檔案描述子0和標準輸入的關聯
第14行 dup()
功用為傳入檔案描述子 A,會回傳檔案描述子 B,且 A 和 B 皆會關連到檔案描述子 A 所關聯的檔案。傳入 p[0]
,會回傳一個檔案描述子關連到 p[0]
所關連到的檔案,也就是 pipe 的讀取端,由於我們解除了檔案描述子0和標準輸入的關聯,因此回傳的檔案描述子為0,0關連到 pipe 的讀取端。
第15行和16行解除 p[0]
和 p[1]
所關聯的檔案。
這樣我們的程式就可以成功從 pipe 的一端讀入資料了。
接著第17行執行 exec()
,變成 wc()
,當 wc()
從檔案描述子0關連到的檔案讀取輸入時,實際上是從 pipe()
的讀取端獲得輸入。
接著親代Process會向pipe的寫入端寫入,並且解除 p[0]
,p[1]
所關聯的檔案。
而之所以在 exec()
之前要執行 close()
,原因為如果在執行 read()
的情況下,read()
會從 pipe 讀取端等待讀入端讀入資料,直到有資料寫入或是指向寫入端的檔案描述子全部關閉,在指向寫入端的檔案描述子全部關閉的情況下,read()
會回傳0,就像是讀取到檔案的 EOF 一樣,如果沒有先執行 close()
,可能會發生 read()
不斷等待的情況。
而我們可以看看在 /user/sh.c/
中是如何進行 pipe 的。
case PIPE:
pcmd = (struct pipecmd*)cmd;
if(pipe(p) < 0)
panic("pipe");
if(fork1() == 0){
close(1);
dup(p[1]);
close(p[0]);
close(p[1]);
runcmd(pcmd->left);
}
if(fork1() == 0){
close(0);
dup(p[0]);
close(p[0]);
close(p[1]);
runcmd(pcmd->right);
}
close(p[0]);
close(p[1]);
wait(0);
wait(0);
break;
首先會在第3行的地方建立 pipe,接著在第5行的地方 fork()
產生出子 Process。
子 Process 會先將檔案描述子1連接到 pipe 的寫入端,接著執行,另外一個子 Process 會將檔案描述子0連接到 pipe 的讀取端,可以看到做法跟我們上面的作法有一些雷同。
在 UNIX 系統中,pipe 被視為一種特殊的檔案型態,因此可以通過 write()
和 read()
等 System call 進行使用,這一點也可以從 xv6 的程式碼中看到相關實作
void
fileclose(struct file *f)
{
struct file ff;
acquire(&ftable.lock);
if(f->ref < 1)
panic("fileclose");
if(--f->ref > 0){
release(&ftable.lock);
return;
}
ff = *f;
f->ref = 0;
f->type = FD_NONE;
release(&ftable.lock);
if(ff.type == FD_PIPE){
pipeclose(ff.pipe, ff.writable);
} else if(ff.type == FD_INODE || ff.type == FD_DEVICE){
begin_op();
iput(ff.ip);
end_op();
}
}
在 kernel/file.c
中 fileclose()
可以看到我們傳入的一個檔案結構,並且會判斷這個檔案是屬於哪一個類型,在這邊可以看到總共判斷了三種檔案類型,分別為FD_PIPE
, PD_INODE
, FD_DEVICE
,pipe 也是屬於一種檔案類型。
我們常常在 Console 上使用到 pipe,將第一個指令的輸出作為第二個指令的輸入。舉例來說,我們可以使用 ls
檢視目前目錄底下的資料夾與檔案,對於一個擁有較多內容的目錄,我們可能需要捲動葉面來查看整個輸出,而使用more
這個指令,我們便可以將大量的輸出變成好幾個頁,通過空白鍵去切換頁面來檢視輸出。ls
和more
都是獨立的一個 process,Shell 會通過fork()
,接著exec()
去執行。
而使用 pipe 的方式,我們可以讓ls()
這個 process 的輸出,作為more()
的輸入
ls | more
(來自於 6S081 的 Lab) 要求 :
Write a program that uses UNIX system calls to ``ping-pong’’ a byte between two processes over a pair of pipes, one for each direction. The parent sends by writing a byte to parent_fd[1] and the child receives it by reading from parent_fd[0]. After receiving a byte from parent, the child responds with its own byte by writing to child_fd[1], which the parent then reads. Your solution should be in the file user/pingpong.c
寫一隻名稱為"pingpong"的程式,pingpong 需要實現兩個 Process 之間通過一對 pipe 進行溝通
parnet_fd[1]
進行寫入,並且子 Process 在 parent_fd[0]
進行讀取,並印出讀取的結果。child_fd[1]
進行寫入,並在親代 Process 於 child_fd[0]
進行讀取,並印出讀取得結果。#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"
int main(void)
{
int parent_fd[2];
int child_fd[2];
char buffer[100];
pipe(parent_fd);
pipe(child_fd);
int pid = fork();
if(pid == 0)//child
{
read(parent_fd[0], buffer, 4);
printf("%d: received ping\n", getpid());
close(parent_fd[0]);
write(child_fd[1], "pong", 4);
close(child_fd[1]);
}
else//parnet
{
write(parent_fd[1], "ping", 4);
close(parent_fd[1]);
read(child_fd[0], buffer, 4);
close(child_fd[0]);
printf("%d: received pong\n", getpid());
}
exit(0);
}
xv6-riscv
Operating System Concepts, 9/e
RISC-V xv6 Book
build a OS
圖片使用 draw.io 進行繪製